-
Notifications
You must be signed in to change notification settings - Fork 1
[Feat] 제보 완료화면 ViewModel 추가 #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Walkthrough리포트 등록·파일 업로드(사전 서명 URL) 경로가 도입되고, 도메인 모델·리포지토리·엔드포인트·UseCase·ViewModel과 네트워크 바디 처리(rawData/json)가 연쇄적으로 변경되어 리포트 작성·사진 업로드·조회 흐름이 재구성되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
participant UI as ReportRegistrationVC
participant VM as ReportRegistrationVM
participant UC as ReportUseCase
participant FR as FileRepository
participant RR as ReportRepository
participant NS as NetworkService
UI->>VM: register() (title, content, category, location, photos)
VM->>UC: report(title, content, category, location, photos)
UC->>FR: fetchPresignedURL(prefix, filenames)
FR-->>UC: presignedDict (filename -> presignedURL)
loop for each photo
UC->>FR: uploadFile(url: presignedURL, data)
FR->>NS: S3Endpoint.uploadImage (PUT rawData)
NS-->>FR: EmptyResponseDTO
end
UC->>RR: report(..., photoURLs) (ReportEndpoint.register POST)
RR->>NS: performRequest (ReportEndpoint.register)
NS-->>RR: BaseResponse / id
RR-->>UC: id
UC-->>VM: reportId
VM-->>UI: reportRegistrationCompletePublisher (reportId)
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes
Possibly related PRs
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🧹 Nitpick comments (1)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (1)
261-270: 사진 썸네일 표현 품질을 위해contentMode설정을 고려해 보세요.현재
UIImageView에 배경색·cornerRadius만 지정되어 있어 실제 이미지를 로드하면 비율이 깨질 수 있습니다. 썸네일 용도라면scaleAspectFill+clipsToBounds(true)조합이 일반적입니다 (masksToBounds = true는 이미 설정되어 있으니contentMode만 추가해도 효과가 있을 것 같습니다).private func makePhotoView() -> UIImageView { let photoView = UIImageView() photoView.backgroundColor = BitnagilColor.gray30 photoView.layer.masksToBounds = true photoView.layer.cornerRadius = 6 + photoView.contentMode = .scaleAspectFill photoView.snp.makeConstraints { make in make.size.equalTo(Layout.photoSize) } return photoView }
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift(5 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 68
File: Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift:108-172
Timestamp: 2025-11-19T13:49:08.326Z
Learning: ReportCompleteViewController에서 backgroudView가 fomoImageView와 의도적으로 겹치도록 설계됨. 이를 위해 backgroudView의 top 제약을 fomoImageView.snp.top (또는 fomoImageView)을 기준으로 설정하여 오프셋을 적용함.
📚 Learning: 2025-11-19T13:49:08.326Z
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 68
File: Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift:108-172
Timestamp: 2025-11-19T13:49:08.326Z
Learning: ReportCompleteViewController에서 backgroudView가 fomoImageView와 의도적으로 겹치도록 설계됨. 이를 위해 backgroudView의 top 제약을 fomoImageView.snp.top (또는 fomoImageView)을 기준으로 설정하여 오프셋을 적용함.
Applied to files:
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift
🧬 Code graph analysis (1)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (2)
Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift (4)
viewDidLoad(71-74)configureAttribute(76-95)configureLayout(97-136)bind(138-145)Projects/Presentation/Sources/Common/DesignSystem/Font/BitnagilFont.swift (1)
attributedString(50-67)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (3)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (3)
8-12: BaseViewController 제네릭 + DI 패턴 적용이 일관되고 적절합니다.
BaseViewController<ReportDetailViewModel>상속과init(viewModel:reportId:)DI 패턴, 그리고cancellables보관 방식이ReportDetailViewController와 일관되어 구조적으로 잘 맞습니다. 스토리보드를 쓰지 않는 화면이므로fatalError기반 coder-init 처리도 지금 컨텍스트에서는 무리 없어 보입니다.Also applies to: 69-80
89-119: 기존 UI 속성 설정을 유지하면서 BaseViewController 패턴에 맞게 잘 정리되어 있습니다.
configureAttribute()를 override로 전환하면서 배경색, 폰트, 색상, 스택뷰 속성 등이 이전 구현 그대로 유지되고 있어, 화면 전반의 스타일링이 BaseViewController 라이프사이클과 자연스럽게 녹아듭니다.
121-185: 레이아웃 구성 및 fomo/background 오버랩 구조가 의도와 잘 맞습니다.
scrollView–contentView를contentLayoutGuide/frameLayoutGuide로 잡은 구조가 스크롤·오토레이아웃 관점에서 정석적인 패턴입니다.- Line 168–171에서
backgroudView.top을fomoImageView(top) 기준으로 두고 오프셋을 주는 방식은, 이전에 공유된 “fomoImageView와 backgroundView가 겹치는 연출” 의도와도 부합해 보입니다.Based on learnings
| override func viewDidLoad() { | ||
| super.viewDidLoad() | ||
| configureAttribute() | ||
| configureLayout() | ||
| fetchReport() | ||
| viewModel.action(input: .fetchReportDetail(reportId: reportId)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
configureAttribute/configureLayout가 두 번 호출될 가능성이 있습니다.
BaseViewController의 viewDidLoad()에서 이미 configureAttribute(), configureLayout(), bind()를 호출하는 패턴이라면, Line 84–85에서 직접 다시 호출하면서 서브뷰 중복 추가·제약 중복 설정이 발생할 수 있습니다. 실제로 레이아웃 쪽은 두 번 도는 순간 Auto Layout 경고나 예기치 않은 뷰 계층 구조가 생길 수 있습니다.
이 컨트롤러도 ReportDetailViewController와 동일하게 super.viewDidLoad() 이후에는 액션만 호출하도록 정리하는 편이 안전해 보입니다.
override func viewDidLoad() {
super.viewDidLoad()
- configureAttribute()
- configureLayout()
viewModel.action(input: .fetchReportDetail(reportId: reportId))
}🤖 Prompt for AI Agents
In Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift
around lines 82–87, remove the duplicate calls to configureAttribute() and
configureLayout() in viewDidLoad() because BaseViewController already invokes
those (and bind()), which can cause duplicate subview additions and constraint
warnings; keep super.viewDidLoad() and only call the report fetch action
(viewModel.action(input: .fetchReportDetail(reportId: reportId))), or
alternatively guard against double-initialization if BaseViewController behavior
changes.
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift
Show resolved
Hide resolved
|
CI 과정 발생한 오류는, 앞 PR 머지 이후 rebase 하면서 같이 작업하는게 좋을 것 같습니다~ |
* feat: NetworkService 로직 수정 - request 생성로직 수정. body에 jsonData 뿐 아니라, rawdata도 들어갈 수 있도록 수정 (s3 업로드 위함) - response body가 empty여도 error를 throw 하지 않도록 수정 * feat: 제보하기 로직 구현 * refactor: 제보하기 수정된 디자인 적용 * feat: 제보하기 필터링 로직 구현 * feat: 제보히스토리 -> 제보상세 플로우 미비사항 구현 - 진행상황 collectionView cell에 진행상황 별 갯수 표시 - ReportEntity id 값을 옵셔널로 변경 - ReportDetail 날짜 포멧 변경 * fix: 제보하기 로직 수정 * refactor: 코드래빗 리뷰 반영
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
Projects/Presentation/Sources/Common/Component/SelectableItemCell.swift (1)
75-102: 셀 재사용 시 아이콘 상태 및 displayName 조건 때문에 아이콘이 잘못 보일 수 있습니다현재
configureCell구현에는 두 가지 잠재적인 문제가 있습니다.
displayName == nil인 항목은 아이콘 로직을 타지 않습니다
guard let displayName = item.displayName else { ... return }이후에만가 호출됩니다.if let icon = item.icon { iconImageView.image = icon iconImageView.isHidden = false }SelectPhotoType는displayName == nil,icon은 non-nil 이므로, 아이콘을 구현했음에도 실제로는 한 번도 보이지 않게 됩니다.셀 재사용 시 이전 아이콘이 남을 수 있습니다
- 어떤 아이템에서
icon이 설정되어iconImageView.isHidden = false가 된 뒤,
다음에icon == nil인 아이템으로 재사용되면,else쪽에서isHidden/image를 리셋하지 않아 이전 아이콘이 그대로 남을 수 있습니다.displayName == nil경로에서는 아예 아이콘에 손대지 않고 곧바로return하기 때문에, 여기서도 같은 문제가 발생할 수 있습니다.두 문제를 함께 해결하려면, 아이콘 설정/리셋을
guard앞에서 공통으로 처리하는 것이 좋습니다. 예시는 다음과 같습니다.func configureCell(item: SelectableItem, isSelected: Bool) { checkIcon.isHidden = !isSelected - guard let displayName = item.displayName else { - titleLabel.text = item.description - return - } - - if let icon = item.icon { - iconImageView.image = icon - iconImageView.isHidden = false - } + // 아이콘은 displayName 여부와 상관없이 항상 갱신 + let icon = item.icon + iconImageView.image = icon + iconImageView.isHidden = (icon == nil) + + guard let displayName = item.displayName else { + titleLabel.attributedText = nil + titleLabel.text = item.description + return + } let attributedString = NSMutableAttributedString(string: item.description) attributedString.addAttribute( .font, value: BitnagilFont(style: .body1, weight: .regular).font, range: NSRange(location: 0, length: item.description.count)) @@ - titleLabel.attributedText = attributedString + titleLabel.attributedText = attributedString }이렇게 정리하면
displayName이 없는 항목(예:SelectPhotoType)도 아이콘은 정상 노출되고,- 셀 재사용 시에도 항상 현재
item.icon기준으로 이미지/hidden 상태가 초기화되어 잘못된 아이콘이 남지 않게 됩니다.Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift (1)
116-125: 등록 버튼 탭 시 로딩 화면/델리게이트 흐름은 좋지만, 한 가지 컴파일 이슈와 중복 실행 가능성이 있습니다
reportRegistrationCompletePublisher바인딩에서delegate앞에self.가 빠져 있어 컴파일이 안 될 것 같습니다.viewModel.output.reportRegistrationCompletePublisher .receive(on: DispatchQueue.main) .sink { [weak self] reportId in guard let self else { return } - delegate?.reportRegistrationViewController(self, completeRegistration: reportId) + self.delegate?.reportRegistrationViewController(self, completeRegistration: reportId) } .store(in: &cancellables)
- 현재 구현에서는 등록 버튼을 여러 번 빠르게 탭하면
ReportLoadingViewController()가 그만큼 push되고,register()도 중복 호출될 수 있습니다. 실제 네트워크/서버 상태에 따라 중복 제보가 생길 여지가 있어서,
- 첫 탭 이후에는 버튼 상태를
.inProgress혹은.disabled로 바꾸고,- 등록 완료/실패 시 다시 활성화
하는 식으로 in‑flight 상태를 ViewModel에서 관리해 주면 더 안전할 것 같습니다.Also applies to: 407-425
♻️ Duplicate comments (1)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (1)
83-88:configureAttribute/configureLayout를viewDidLoad에서 다시 호출하면 중복 초기화가 발생할 수 있습니다
BaseViewController의viewDidLoad()에서 이미configureAttribute(),configureLayout(),bind()를 호출하는 패턴이라면, 여기서 다시 직접 호출하면 서브뷰 추가·제약 설정이 두 번씩 일어나 Auto Layout 경고나 예기치 않은 UI 결과가 나올 수 있습니다 (이전에 같은 이슈가 한 번 지적된 적 있습니다).이 컨트롤러도
ReportDetailViewController와 동일하게super.viewDidLoad()이후에는 액션만 호출하도록 정리하는 것이 안전해 보입니다.override func viewDidLoad() { super.viewDidLoad() - configureAttribute() - configureLayout() viewModel.action(input: .fetchReportDetail(reportId: reportId)) }
🧹 Nitpick comments (17)
Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift (1)
8-9: 카메라/앨범 타입별 아이콘 매핑 무난합니다
SelectableItem.icon요구사항에 맞게BitnagilIcon을 사용해 명확히 매핑되어 있고, 옵셔널 처리도 자연스럽습니다.
템플릿 컬러로 틴팅해야 하는 디자인이라면, 에셋 쪽에서withRenderingMode(.alwaysTemplate)적용 여부만 한 번 확인해 두면 좋겠습니다.Also applies to: 38-45
Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift (1)
79-80: 리포트용 카메라/사진 아이콘 추가 이상 없습니다새 에셋 이름(
bitnagil_camera_icon,bitnagil_photo_icon)과SelectPhotoType.icon사용처가 일관되고, 다른 아이콘 정의 방식과도 형식이 동일해서 유지보수에 문제 없어 보입니다.
필요하다면 이 아이콘도 다른 곳처럼 템플릿 렌더링(withRenderingMode(.alwaysTemplate))을 사용할지 디자인 가이드에 맞춰 통일만 고민해 보시면 될 것 같습니다.Projects/Domain/Sources/Protocol/Repository/FileRepositoryProtocol.swift (1)
1-23: Presigned URL 반환 타입의 옵셔널 여부 한 번만 정리해 보면 좋겠습니다
fetchPresignedURL이[String: String]?를 반환하도록 설계되어 있는데,FileRepository구현에서는 현재 항상 non-optional 딕셔너리를 리턴하고 있어 실제로nil이 나올 상황이 거의 없어 보입니다.
- presigned URL 발급이 실패하면
throw로 처리하고- 성공 시에는 항상 모든 파일 이름에 대한 URL 을 받는 구조라면
반환 타입을
[String: String]로 단순화해도 좋을 것 같습니다. 반대로, 일부 케이스에서 응답 바디가 없거나 비어 있을 수 있다면, 그 시나리오를 주석에 조금 더 명시해 두면 호출 측에서 이해하기 쉬울 것 같습니다.Projects/Presentation/Sources/Common/Component/SelectableItemCell.swift (1)
14-18: 스택뷰 기반 레이아웃 전환은 괜찮습니다 (axis/size 세부 설정만 참고)
stackView로 아이콘 + 타이틀을 묶고, 고정 높이/마진을 주는 구조는 명확하고 유지보수도 좋아 보입니다.
소소하게 고려해 볼 만한 부분은 아래 정도입니다.
stackView.axis는 기본값이.horizontal이라 현재도 동작에는 문제 없지만, 명시적으로.horizontal을 지정해 두면 의미가 더 분명해집니다.iconImageView는 크기를 20pt 정사각형으로 고정해 두셨는데, 아이콘 에셋 비율에 따라 간혹 찌그러질 수 있어contentMode = .scaleAspectFit정도를 설정해 두면 안전합니다.stackView에width == 0(priority .medium) 제약을 주신 부분은 레이아웃 경고 방지용 의도로 보이는데, 나중에 디버깅할 때 혼란을 줄 수 있으니, 실제로 필요한지 한 번 더 확인해 보시고 필요 없다면 제거하는 것도 옵션입니다.전반적으로 기능적인 문제는 없어 보이고, 위 내용은 선택적인 정리 사항에 가깝습니다.
Also applies to: 20-22, 35-49, 51-66
Projects/DataSource/Sources/Repository/ReportRepository.swift (1)
14-38: 신규report구현은 일관적이지만,nilid 의미를 명확히 해두는 편이 좋습니다
ReportDTO구성과ReportEndpoint.register호출 흐름은ReportUseCase.report와 잘 맞습니다. 다만networkService.request결과가nil인 경우를 그대로nilid로 올려보내고 있어서, 상위 레이어에서 “요청 실패(에러)”와 “정상 응답이지만 id 없음”을 구분하기가 애매할 수 있습니다.도메인에서 이 함수를 호출하는 쪽과 논의해,
- “id가 반드시 내려와야 한다”는 계약이라면
nil대신 명시적인 도메인 에러를 throw 하거나,- “id가 없을 수도 있다”는 스펙이라면 그 의미를 주석/문서에 남겨 두는 것
을 고려해 보시면 좋겠습니다.Projects/Domain/Sources/Entity/Enum/ReportType.swift (1)
8-18:rawValue가 이미 대문자라면uppercased()는 불필요합니다지금 케이스들의
rawValue가"TRANSPORTATION","LIGHTING"등 이미 모두 대문자라서,description에서 한 번 더uppercased()를 호출할 필요는 없습니다. 동작에는 문제가 없지만, 간결성을 위해 아래처럼 단순화할 수 있습니다.-extension ReportType: CustomStringConvertible { - public var description: String { - return self.rawValue.uppercased() - } -} +extension ReportType: CustomStringConvertible { + public var description: String { + rawValue + } +}추후 소문자
rawValue를 도입해 API용 문자열을 따로 관리하고 싶다면,description대신apiValue같은 별도 프로퍼티를 두는 것도 고려해 볼 수 있습니다.Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swift (1)
83-95: 제보 완료 화면 네비게이션 플로우 및 TODO 정리 제안
reportId유무에 따라 완료 화면으로 push / 현재 VC pop 처리하는 delegate 플로우는 자연스럽습니다.
다만 Line 90의 TODO는 이미reportId를 생성자 인자로 전달하고 있어 다소 중복·모호해 보이니, 최종적으로 id만 넘길지/엔티티 전체를 넘길지 결정된 뒤 TODO를 제거하거나 더 구체적인 FIXME 또는 이슈 링크로 정리해 두면 좋겠습니다.
아울러ReportDetailViewModel의존성이 Domain 쪽 DIAssembler에 정상 등록돼 있는지만 한 번만 확인 부탁드립니다.Projects/Presentation/Sources/Home/View/HomeViewController.swift (1)
667-677: Report 제보 플로우의 ViewModel/문구 불일치
ReportRegistrationViewModel을 resolve 해서ReportRegistrationViewController로 push 하는 구성은 새 제보 플로우와 일관됩니다.
다만fatalError메시지에는"reportRegistrationViewController 의존성"이라고 되어 있어 실제 resolve하는 타입(ViewModel)과 이름이 맞지 않으니,"reportRegistrationViewModel 의존성이 등록되지 않았습니다."처럼 문구를 맞춰 두면 디버깅 시 더 명확할 것 같습니다.Projects/Domain/Sources/Protocol/UseCase/ReportUseCaseProtocol.swift (1)
8-38: ReportUseCaseProtocol 메서드 시그니처/주석 정합성 양호
fetchReports / fetchReport / report(title:content:category:location:photos:)추가가 실제ReportUseCase구현과 잘 맞고, 사진 업로드까지 포함한 도메인 요구사항이 명확히 드러납니다.
선택 사항이지만,fetchCurrentLocation·fetchReport처럼nil을 반환할 수 있는 메서드에 대해 주석에 "조건 불충족 시 nil" 정도만 추가해 두면 호출 측에서 API를 이해하기 조금 더 수월할 것 같습니다.Projects/DataSource/Sources/Endpoint/S3UploadEndpoint.swift (1)
10-63: S3Endpoint baseURL/path 및 헤더 조건 한 번만 확인 필요presigned URL을 그대로
path로 넘기고baseURL을 빈 문자열로 두는 구현은, 네트워크 레이어에서baseURL·path를 어떻게 조합하는지에 따라 동작이 달라질 수 있습니다. 만약URL(string: baseURL + path)처럼 단순 문자열 결합이면 문제 없지만,appendingPathComponent를 사용하는 경우 전체 URL이 잘못 조합될 수 있으니 NetworkService 구현과 함께 한 번만 확인해 주세요.또한 presigned URL을 생성할 때
Content-Type등 특정 헤더를 서명에 포함시키는지에 따라, 여기서headers를 빈 딕셔너리로 두면 업로드가 실패할 가능성도 있습니다. 백엔드와 프로토콜을 맞춰 필요하다면 예를 들어"Content-Type": "image/jpeg"같은 헤더를 추가해 두는 편이 안전합니다.Projects/Domain/Sources/Entity/ReportEntity.swift (1)
19-39: thumbnailURL / photoURLs 필드 추가는 적절하나 이름 일관성 제안도메인 엔티티에
thumbnailURL과photoURLs를 추가하고id를 Optional로 바꾼 변경은 상위 레이어(예: 히스토리/상세 뷰모델)에서의 사용 방식과 잘 맞습니다.
다만 이니셜라이저의 파라미터 이름이photoUrls이고 프로퍼티는photoURLs라서 대소문자 규칙이 달라 약간 혼동될 수 있습니다. 아래처럼 파라미터 이름도photoURLs로 맞추면 더 일관될 것 같습니다.- thumbnailURL: String?, - photoUrls: [String] + thumbnailURL: String?, + photoURLs: [String] @@ - self.thumbnailURL = thumbnailURL - self.photoURLs = photoUrls + self.thumbnailURL = thumbnailURL + self.photoURLs = photoURLsProjects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift (2)
23-33: 검증/완료 Publisher의 Subject 설계 한 번만 점검해 보면 좋겠습니다
isReportValid를PassthroughSubject로 두면, 구독 시점 이후 이벤트만 전달돼서 중간에 새로 구독하는 쪽은 현재 상태를 못 받을 수 있습니다. 뷰모델 라이프사이클 상 항상 뷰와 함께 생성·바인딩된다면 문제는 없지만, 혹시 재바인딩/재구독 가능성이 있다면CurrentValueSubject<Bool, Never>로 바꿔 두는 편이 더 안전합니다. 반면 등록 완료 이벤트(reportRegistrationCompleteSubject)는 일회성 이벤트라PassthroughSubject선택이 적절해 보입니다.Also applies to: 37-44, 52-61
134-172: 검증 로직과register()의 guard 조건을 한 곳으로 모아 두면 유지보수가 더 수월할 것 같습니다
verifyIsReportValid()와register()의guard가 거의 동일한 조건을 중복해서 가지고 있습니다. 현재는verifyIsReportValid쪽만 lat/long까지 확인하고 있어, 나중에 요구사항이 바뀌면 두 곳을 동시에 수정해야 합니다.예를 들어 “필수값이 모두 채워졌는지 판단하는 private computed property”를 두고, 그 값을
isReportValid발행과register()의 guard 양쪽에서 참조하도록 통합하면 조건 누락/불일치 위험이 줄어듭니다.Also applies to: 174-190
Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift (1)
47-49: 내용 글자 수 카운트 레이블의 최대 글자 수(150)를 상수로 분리해 두는 것이 좋아 보입니다
"150"이 레이블 텍스트("\(content.count) / 150"), UI/기획 스펙 양쪽에서 모두 쓰이는 매직 넘버라서, 예를 들어Layout내부나 별도 상수(static let maxContentLength = 150)로 정의해 두고 동일 값을 참조하면 나중에 정책이 바뀔 때 한 군데만 수정하면 되어 유지보수성이 좋아질 것 같습니다.Also applies to: 67-69, 127-129, 284-294, 371-378
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (1)
204-235:bind()에서summaryStackView구성은 한 번만 만들고, 이후에는 값만 갱신하는 구조가 더 안전합니다현재는
reportDetailPublisher가 emit될 때마다ReportCompleteContent.allCases를 순회하며makeContentView결과를summaryStackView에 계속 추가하고 있습니다.photoStackView는 매번 비워 주고 있지만,summaryStackView는 정리 없이 늘어날 수 있어(emit 1회라는 보장이 깨질 경우) 중복 뷰가 쌓일 여지가 있습니다.
configureLayout()혹은configureAttribute()에서ReportCompleteContent에 대한 컨텐츠 뷰를 한 번만 만들고 stack에 추가한 뒤,bind()에서는 각각의 레이블/이미지뷰에 값만 채우는 방식으로 분리하면, emit 횟수와 관계없이 UI 구조가 안정적으로 유지됩니다.
Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift (2)
34-35: 진행 상태(progress) 필터가 이제 항상 하나만 선택되는 구조인데, 토글 해제 UX가 필요하지 않은지 확인이 필요합니다
filterProgress에서 선택된 progress만isSelected = true로 만들고,selectedProgress를 항상 해당 값으로 갱신한 뒤filterReports()에서는if let selectedProgress, selectedProgress != .entire { filteredReports = filteredReports.filter { $0.progress == selectedProgress } }로 동작하게 되어 있어, 한 번 progress를 누르면 다시 눌러도 해제(=전체로 돌아감)는 되지 않고, 대신 “전체” 칩을 눌러야만 전체 보기로 돌아가게 됩니다.
기획/UX가 “항상 하나의 진행 상태가 선택된 상태를 유지한다”는 의도라면 현재 구현이 적절해 보이고,
이전처럼 같은 항목을 다시 탭하면 필터를 해제하는 토글 UX를 원한다면, 동일 progress를 다시 선택했을 때selectedProgress를nil또는.entire로 되돌리는 로직이 필요합니다.Also applies to: 85-100, 102-117
119-163:fetchReports()의 맵핑/카운트 로직은 도메인 변경에 잘 맞게 정리된 것 같습니다
reportEntity.id를guard let으로 필터링하고,- 날짜 문자열을
Date.convertToDate→convertToString으로 변환한 뒤,thumbnailURL과progress를 그대로 반영해ReportHistoryItem을 만드는 구조가 도메인 계층 변경과 일관되게 맞춰져 있습니다.
또한, 이후에 다시ReportProgress.allCases를 순회하면서 전체/각 상태별 count를 재계산해progressSubject에 흘려주는 부분도 자연스럽습니다.별다른 버그 포인트는 안 보이고, 필요하다면 나중에 이 맵핑 로직을 별도 mapper로 분리하는 정도만 고려해 보면 될 것 같습니다.
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (6)
Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/bitnagil_camera_icon.pngis excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/[email protected]is excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/[email protected]is excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/bitnagil_photo_icon.pngis excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/[email protected]is excluded by!**/*.pngProjects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/[email protected]is excluded by!**/*.png
📒 Files selected for processing (37)
Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift(1 hunks)Projects/DataSource/Sources/Common/Enum/Endpoint.swift(2 hunks)Projects/DataSource/Sources/Common/Enum/EndpointBodyType.swift(1 hunks)Projects/DataSource/Sources/DTO/FilePresignedConditionDTO.swift(1 hunks)Projects/DataSource/Sources/DTO/FilePresignedDTO.swift(1 hunks)Projects/DataSource/Sources/DTO/ReportDTO.swift(2 hunks)Projects/DataSource/Sources/Endpoint/FilePresignedEndpoint.swift(1 hunks)Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift(3 hunks)Projects/DataSource/Sources/Endpoint/S3UploadEndpoint.swift(1 hunks)Projects/DataSource/Sources/NetworkService/Extension/Endpoint+.swift(1 hunks)Projects/DataSource/Sources/NetworkService/NetworkService.swift(1 hunks)Projects/DataSource/Sources/Repository/FileRepository.swift(1 hunks)Projects/DataSource/Sources/Repository/ReportRepository.swift(1 hunks)Projects/Domain/Sources/DomainDependencyAssembler.swift(1 hunks)Projects/Domain/Sources/Entity/Enum/ReportType.swift(1 hunks)Projects/Domain/Sources/Entity/ReportEntity.swift(2 hunks)Projects/Domain/Sources/Protocol/Repository/FileRepositoryProtocol.swift(1 hunks)Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift(1 hunks)Projects/Domain/Sources/Protocol/UseCase/ReportUseCaseProtocol.swift(1 hunks)Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift(2 hunks)Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/Contents.json(1 hunks)Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/Contents.json(1 hunks)Projects/Presentation/Sources/Common/Component/SelectableItemCell.swift(5 hunks)Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift(1 hunks)Projects/Presentation/Sources/Common/Protocol/SelectableItem.swift(1 hunks)Projects/Presentation/Sources/Home/View/HomeViewController.swift(1 hunks)Projects/Presentation/Sources/Report/Model/SelectPhotoType.swift(2 hunks)Projects/Presentation/Sources/Report/View/Component/ReportHistory/ReportHistoryTableViewCell.swift(1 hunks)Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swift(3 hunks)Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift(5 hunks)Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift(2 hunks)Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swift(2 hunks)Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift(13 hunks)Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift(2 hunks)Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift(4 hunks)Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift(5 hunks)Projects/Shared/Sources/Extension/Date+.swift(2 hunks)
✅ Files skipped from review due to trivial changes (4)
- Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_camera_icon.imageset/Contents.json
- Projects/DataSource/Sources/Common/Enum/EndpointBodyType.swift
- Projects/Presentation/Resources/Images.xcassets/Report/bitnagil_photo_icon.imageset/Contents.json
- Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-11-22T11:34:16.094Z
Learnt from: taipaise
Repo: YAPP-Github/Bitnagil-iOS PR: 73
File: Projects/DataSource/Sources/DTO/FilePresignedDTO.swift:8-8
Timestamp: 2025-11-22T11:34:16.094Z
Learning: In the Bitnagil-iOS project, DTOs in the DataSource module like FilePresignedDTO are intentionally kept internal (without public access modifier) when they are only used within the DataSource module and not exposed to other modules.
Applied to files:
Projects/DataSource/Sources/DTO/FilePresignedDTO.swiftProjects/DataSource/Sources/DTO/FilePresignedConditionDTO.swift
📚 Learning: 2025-07-27T10:05:46.043Z
Learnt from: taipaise
Repo: YAPP-Github/Bitnagil-iOS PR: 25
File: Projects/Presentation/Sources/RoutineCreation/View/Component/RoutineCreationInputView.swift:104-111
Timestamp: 2025-07-27T10:05:46.043Z
Learning: RoutineCreationInputView에서 textField(_:shouldChangeCharactersIn:replacementString:) 메서드가 변경 전 텍스트를 delegate에 전달하는 이유: 변경된 텍스트를 전달하면 텍스트 입력 중에 다른 UI 액션(예: 서브루틴 추가)이 발생할 때 입력 중이던 텍스트가 손실되는 문제가 발생하기 때문. 이는 의도적인 구현으로 동시성 문제를 방지함.
Applied to files:
Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swift
📚 Learning: 2025-11-19T13:49:08.326Z
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 68
File: Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift:108-172
Timestamp: 2025-11-19T13:49:08.326Z
Learning: ReportCompleteViewController에서 backgroudView가 fomoImageView와 의도적으로 겹치도록 설계됨. 이를 위해 backgroudView의 top 제약을 fomoImageView.snp.top (또는 fomoImageView)을 기준으로 설정하여 오프셋을 적용함.
Applied to files:
Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swiftProjects/Presentation/Sources/Home/View/HomeViewController.swiftProjects/Presentation/Sources/Report/View/ReportRegistrationViewController.swiftProjects/Presentation/Sources/Report/View/ReportCompleteViewController.swift
📚 Learning: 2025-07-16T09:09:13.869Z
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 19
File: Projects/Presentation/Sources/Login/View/TermsAgreementView.swift:44-46
Timestamp: 2025-07-16T09:09:13.869Z
Learning: BaseViewController의 viewDidLoad() 메서드에서 이미 configureAttribute(), configureLayout(), bind()를 호출하므로, 하위 클래스에서 super.viewDidLoad()를 호출하면 이 메서드들이 자동으로 호출된다. 따라서 하위 클래스에서 추가로 호출할 필요가 없다.
Applied to files:
Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swiftProjects/Presentation/Sources/Report/View/ReportCompleteViewController.swift
📚 Learning: 2025-07-16T09:21:15.038Z
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 19
File: Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift:57-59
Timestamp: 2025-07-16T09:21:15.038Z
Learning: OnboardingRecommendedRoutineView에서 viewWillAppear에 registerOnboarding 호출하는 것이 적절한 이유: 사용자가 이전 페이지에서 온보딩 선택지를 변경한 후 돌아올 때 새로운 선택지로 다시 등록해야 하기 때문. 홈 뷰에서는 이 뷰로 돌아올 수 없어서 중복 호출 문제가 발생하지 않음.
Applied to files:
Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swiftProjects/Presentation/Sources/Home/View/HomeViewController.swift
🧬 Code graph analysis (17)
Projects/Domain/Sources/Protocol/Repository/FileRepositoryProtocol.swift (1)
Projects/DataSource/Sources/Repository/FileRepository.swift (2)
fetchPresignedURL(14-19)uploadFile(21-24)
Projects/Domain/Sources/DomainDependencyAssembler.swift (1)
Projects/Shared/Sources/DIContainer/DIContainer.swift (1)
resolve(18-25)
Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift (2)
Projects/DataSource/Sources/Repository/ReportRepository.swift (1)
report(14-39)Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (1)
report(39-80)
Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swift (2)
Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift (1)
bitnagilChevronIcon(20-22)Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift (1)
reportTextViewDidChanged(520-528)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (2)
Projects/DataSource/Sources/Repository/ReportRepository.swift (3)
fetchReports(41-53)fetchReportDetail(55-61)report(14-39)Projects/DataSource/Sources/Repository/FileRepository.swift (2)
fetchPresignedURL(14-19)uploadFile(21-24)
Projects/DataSource/Sources/NetworkService/Extension/Endpoint+.swift (1)
Projects/DataSource/Sources/NetworkService/Extension/URLRequest+.swift (1)
makeBodyParameter(33-36)
Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swift (1)
Projects/Shared/Sources/DIContainer/DIContainer.swift (1)
resolve(18-25)
Projects/Presentation/Sources/Home/View/HomeViewController.swift (2)
Projects/Shared/Sources/DIContainer/DIContainer.swift (1)
resolve(18-25)Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swift (1)
reportRegistrationViewController(84-95)
Projects/DataSource/Sources/Repository/FileRepository.swift (1)
Projects/DataSource/Sources/NetworkService/NetworkService.swift (1)
request(25-48)
Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift (2)
Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swift (1)
reportRegistrationViewController(84-95)Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift (2)
action(64-81)register(134-172)
Projects/DataSource/Sources/Endpoint/FilePresignedEndpoint.swift (1)
Projects/DataSource/Sources/Repository/FileRepository.swift (1)
fetchPresignedURL(14-19)
Projects/DataSource/Sources/Repository/ReportRepository.swift (2)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (1)
report(39-80)Projects/DataSource/Sources/NetworkService/NetworkService.swift (1)
request(25-48)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (4)
Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift (3)
viewDidLoad(71-74)configureAttribute(76-95)bind(138-145)Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift (2)
action(32-37)fetchReportDetail(39-61)Projects/Shared/Sources/DIContainer/DIContainer.swift (1)
resolve(18-25)Projects/Presentation/Sources/Common/DesignSystem/Font/BitnagilFont.swift (1)
attributedString(50-67)
Projects/Domain/Sources/Protocol/UseCase/ReportUseCaseProtocol.swift (3)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (4)
fetchCurrentLocation(25-29)fetchReports(31-33)fetchReport(35-37)report(39-80)Projects/DataSource/Sources/Repository/ReportRepository.swift (2)
fetchReports(41-53)report(14-39)Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift (1)
fetchReports(119-167)
Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift (2)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (2)
fetchCurrentLocation(25-29)report(39-80)Projects/DataSource/Sources/Repository/ReportRepository.swift (1)
report(14-39)
Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift (1)
Projects/Shared/Sources/Extension/Date+.swift (2)
convertToDate(24-30)convertToString(16-22)
Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift (1)
Projects/Shared/Sources/Extension/Date+.swift (2)
convertToDate(24-30)convertToString(16-22)
🪛 SwiftLint (0.57.0)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift
[Warning] 50-50: TODOs should be resolved (- 사진 업로드 실패 시 에러 처리 필요)
(todo)
Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swift
[Warning] 90-90: TODOs should be resolved (- reportCompleteViewController...)
(todo)
🔇 Additional comments (25)
Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swift (2)
29-30: 쉐브론 아이콘 업데이트가 적절합니다.디자인 시스템 개선으로 아이콘 크기와 에셋 참조가 업데이트되었습니다. 변경사항이 명확하고 구현이 정확합니다.
Also applies to: 59-59
161-161: 실시간 텍스트 업데이트로 인한 중복 호출 및 성능 검증이 필요합니다.검증 결과,
textViewDidChange(Line 161)와textViewDidEndEditing(Line 166)에서 동일한 delegate 메서드가 호출됩니다. 사용자가 편집을 종료할 때 마지막 입력으로 인한 호출(Line 161) + 편집 종료 호출(Line 166)이 중복 발생합니다.또한
ReportRegistrationViewModel의configureTitle/configureContent메서드에서 매 입력마다verifyIsReportValid()를 호출하고 있습니다. 다음을 확인해주세요:
- 중복 호출 의도 확인: 두 시점의 호출이 모두 필요한지, 아니면 하나로 통합해야 하는지 검토
- 성능 영향 평가:
verifyIsReportValid()구현에서 무거운 작업(유효성 검사, 네트워크 호출 등)이 있는 경우 디바운싱 적용 고려- 팀 정책 확인: 다른 입력 뷰와의 일관성 검토 (예: RoutineCreationInputView의 패턴과 비교)
Projects/Shared/Sources/Extension/Date+.swift (1)
35-36: 새 DateType 포맷 추가 적절합니다
yearMonthDateWeek,yearMonthDateWeek2포맷이 기존 패턴(yyyy-MM-dd,yy.MM.dd)과 잘 맞고,ko_KR로케일에서 요일을 짧게 보여주기에도 무난해 보입니다.
해당 케이스를 사용하는 ViewModel 쪽에서 문자열 파싱(convertToDate)까지 필요하다면, 역변환에 사용되는 포맷도 동일한지 정도만 한 번 더 점검해 주세요.Also applies to: 49-50
Projects/Presentation/Sources/Common/Protocol/SelectableItem.swift (1)
8-9:SelectableItem에icon추가 설계 괜찮습니다프로토콜에
icon: UIImage?를 추가하면서, 기본 구현에서nil을 반환하도록 해서 기존 conformer 들이 깨지지 않는 점이 좋습니다.
이 프로토콜이 Presentation 모듈에만 쓰인다면 UIKit 의존성도 수용 가능한 수준이라 보입니다. 다른 모듈(예: Domain)로 확장할 계획이 있다면, 그때는 아이콘 정보를 별도의 value type 으로 추상화하는 정도만 염두에 두면 될 것 같습니다.Also applies to: 14-19
Projects/DataSource/Sources/Common/Enum/Endpoint.swift (1)
8-9:Endpoint의 bodyType/bodyData 확장은 방향성 좋습니다JSON 기반 기존 엔드포인트는
bodyType = .json,bodyParameters만 사용하고, 파일 업로드 등은bodyType = .rawData,bodyData를 사용하는 구조로 잘 분리된 것 같습니다.
다만Endpoint+.swift에서bodyType에 따라httpBody생성 시bodyParameters와bodyData가 동시에 사용되지 않도록(하나는 반드시 무시되도록) 분기 처리가 되어 있는지만 한 번만 확인해 주세요.Also applies to: 18-25
Projects/DataSource/Sources/DTO/ReportDTO.swift (4)
10-10: LGTM!
Codable채택이 적절합니다.ReportDTO가 이제 네트워크 요청 본문(인코딩)과 응답(디코딩) 모두에 사용되므로,Decodable에서Codable로의 변경이 타당합니다.
16-16: LGTM!
reportStatus를 옵셔널로 변경하고 안전한 기본값 매핑을 사용하는 것이 적절합니다. 이중 폴백 패턴(?? "" ?? .received)이 nil 또는 유효하지 않은 값을 안전하게 처리합니다.Also applies to: 29-29, 46-46
35-35: LGTM!
reportImageUrl를thumbnailURL로 매핑하는 것이 적절하며, 도메인 엔티티의 업데이트와 일관성을 유지합니다.Also applies to: 52-52
23-37:reportIdguard 불일치는 의도된 설계 패턴입니다.ReportEntity의
id필드가Int?(선택형)로 정의되어 있으므로, 두 메서드의 서로 다른 guard 처리는 의도적입니다:
toReportEntity():reportId를 guard하지 않음 → 새 리포트(서버가 아직 ID를 할당하지 않은 경우)용toReportEntity(date:):guard let reportId유지 → 기존 리포트(이미 서버에서 ID를 할당받은 경우)용실제 사용 패턴도 이를 확인해줍니다:
- 단일 리포트 fetch (ReportRepository:60): 새로운 리포트 처리
- 그룹화된 과거 리포트 (ReportRepository:48): 기존 리포트 처리
일관성 있는 설계이므로 추가 조치가 필요하지 않습니다.
Projects/Presentation/Sources/Report/View/Component/ReportHistory/ReportHistoryTableViewCell.swift (1)
49-50: LGTM!셀 선택 스타일을 비활성화하여 탭 시 하이라이트가 표시되지 않도록 합니다. 이는 적절한 UI 개선입니다.
Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift (1)
52-54: LGTM!
FileRepositoryProtocol에 대한 DI 등록이 적절합니다. 기존 등록 패턴과 일관성을 유지하며, 새로운 파일 업로드 기능을 지원합니다.Projects/DataSource/Sources/DTO/FilePresignedConditionDTO.swift (1)
8-11: LGTM!
FilePresignedConditionDTO구조가 적절합니다. 프리사인드 URL 요청 조건을 위한 간단하고 명확한 DTO이며,Codable채택이 올바릅니다. Based on learnings, DataSource 모듈 내에서만 사용되는 DTO는 의도적으로 internal로 유지됩니다.Projects/DataSource/Sources/NetworkService/NetworkService.swift (1)
81-83: LGTM!
EmptyResponseDTO에 대한 특수 케이스 처리가 적절합니다. 빈 응답(예: 204 No Content)을 반환하는 엔드포인트를 올바르게 처리하며, 기존 디코딩 로직에 영향을 주지 않습니다.Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift (3)
10-10: LGTM!
Foundationimport 추가가 적절합니다.Date타입 사용을 위해 필요합니다.
53-53: LGTM!
photoUrls에서photoURLs로 변경이 도메인 엔티티 업데이트와 일치합니다.
43-47: 날짜 포맷 변환 로직 검증 완료검증 결과:
- ✅
yearMonthDate및yearMonthDateWeek2모두Date+.swift에 정의됨 (33번, 36번 줄)- ✅ 형식 정의가 일치:
yearMonthDate = "yyyy-MM-dd"→yearMonthDateWeek2 = "yyyy-MM-dd (E)"- ✅
reportEntity.date가 nil이거나 잘못된 형식일 경우 빈 문자열로 폴백되는 것이 의도된 동작이며, 옵셔널 체이닝과 nil 병합 연산자로 안전하게 처리됨로직이 정확하고 모든 요구사항이 충족되었습니다.
Projects/Presentation/Sources/Report/View/ReportHistoryViewController.swift (2)
270-270: LGTM!
$0.date직접 사용이 적절합니다.ReportHistoryItem.date가 이제 non-optional이 되어?? ""폴백이 더 이상 필요하지 않습니다. 이는 날짜 처리 개선과 일관성을 유지합니다.
282-282: 스냅샷 애니메이션 비활성화를 확인해주세요.
animatingDifferences가true에서false로 변경되었습니다. 이것이 의도된 것인지 확인해주세요:
- 성능 최적화를 위한 것인가요?
- 애니메이션 중 시각적 버그를 방지하기 위한 것인가요?
변경 이유가 명확하지 않다면, 사용자 경험 관점에서 애니메이션을 유지하는 것을 고려해보세요.
Projects/DataSource/Sources/NetworkService/Extension/Endpoint+.swift (1)
15-22: 검증 완료 - 모든 엔드포인트가 정상적으로 작동합니다.검증 결과,
bodyType기반 요청 본문 처리 로직은 완벽하게 구현되었습니다:
- 기본 구현: Endpoint 프로토콜의 확장에서
bodyType: .json,bodyData: nil기본값 제공으로 8개 엔드포인트가 안전하게 작동- rawData 엔드포인트: FilePresignedEndpoint, S3Endpoint 모두
bodyData를 명시적으로 구현하여 올바르게 동작- 안전한 처리: switch 문에서
if let data = bodyData옵셀 체크로 rawData 경우를 안전하게 처리기존 엔드포인트 모두 호환성을 유지합니다.
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (1)
31-37: 리포트 조회 관련 메서드는 단순 위임으로 무난합니다
fetchReports와fetchReport(reportId:)가 각각ReportRepositoryProtocol에 그대로 위임하는 구조는 계층 역할 분리에 잘 맞고, 예외 처리도 상위로 투명하게 전파돼 자연스럽습니다. 별도 수정 필요해 보이지 않습니다.Projects/DataSource/Sources/DTO/FilePresignedDTO.swift (1)
8-18:FilePresignedDTO는 사용되지 않는 미사용 코드이므로 제거해야 합니다검색 결과
FilePresignedDTO는 정의되었을 뿐 코드베이스의 어떤 곳에서도 실제로 사용되지 않고 있습니다. 헤더 주석 외에는 참조가 전혀 없으므로 이 구조체는 제거하는 것이 좋겠습니다.Projects/DataSource/Sources/DTO/FilePresignedDTO.swift 파일 자체를 삭제하거나, 추후 실제 사용할 계획이 있다면 Repository나 Endpoint 계층에서 이 DTO를 활용하도록 연결해 주시기 바랍니다.
⛔ Skipped due to learnings
Learnt from: taipaise Repo: YAPP-Github/Bitnagil-iOS PR: 73 File: Projects/DataSource/Sources/DTO/FilePresignedDTO.swift:8-8 Timestamp: 2025-11-22T11:34:16.094Z Learning: In the Bitnagil-iOS project, DTOs in the DataSource module like FilePresignedDTO are intentionally kept internal (without public access modifier) when they are only used within the DataSource module and not exposed to other modules.Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swift (1)
8-8: Shared 모듈 import 추가 적절Bitnagil* 관련 심볼과 DIContainer를 사용하는 파일이라 Shared import 추가가 필요해 보입니다. 별도 이슈 없습니다.
Projects/Domain/Sources/DomainDependencyAssembler.swift (1)
66-77: ReportUseCase DI에 FileRepository 의존성 추가 OK
locationRepository / reportRepository / fileRepository를 한 번에 resolve 해서ReportUseCase를 주입하는 구성이 현재 UseCase 초기화 시그니처와 잘 맞습니다. 다만 상위 assembler에서FileRepositoryProtocol등록이 누락돼 있으면 여기서fatalError로 앱이 종료되므로, 해당 등록이 포함돼 있는지만 한 번 확인해 주세요.Projects/DataSource/Sources/Repository/FileRepository.swift (1)
11-24: FileRepository 구현이 presigned URL + 업로드 플로우와 잘 맞습니다
fetchPresignedURL에서 조건 DTO 배열을 만들어 한 번에 presigned URL을 받아오고,uploadFile에서S3Endpoint.uploadImage를 통해 rawData PUT 요청을 보내는 구조가 간결하고, 도메인 계층에서 기대하는FileRepositoryProtocol인터페이스와도 잘 맞습니다. 현재 수준에서는 별도 수정 없이도 충분해 보입니다.Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (1)
284-293:makePhotoView()구현은 역할 분리가 잘 되어 있습니다사진 썸네일용 뷰를 별도 팩토리 메서드로 빼고, 크기/코너 라운드/배경색을 한 곳에서 관리하는 구조가 깔끔합니다. 이후 다른 화면에서 동일 스타일 썸네일이 필요해지면 이 메서드를 재사용할 수 있을 것 같습니다.
| var method: HTTPMethod { | ||
| switch self { | ||
| case .fetchPresignedURL: .post | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
method 프로퍼티에서 return 누락으로 컴파일 에러가 발생합니다
switch 내부에서 .post를 리턴하지 않아 현재 코드는 빌드되지 않습니다. 아래처럼 return을 명시해야 합니다.
var method: HTTPMethod {
switch self {
- case .fetchPresignedURL: .post
+ case .fetchPresignedURL:
+ return .post
}
}추가로, 이 케이스가 하나뿐이라면 switch 대신 단순히 return .post로 구현해도 충분합니다.
🤖 Prompt for AI Agents
In Projects/DataSource/Sources/Endpoint/FilePresignedEndpoint.swift around lines
29 to 33, the computed property `method` currently uses a `switch` but doesn't
return the `.post` value, causing a compile error; fix it by either adding an
explicit `return .post` inside the `case .fetchPresignedURL:` branch or simplify
the implementation to directly `return .post` (preferred if this enum has only
that single case), ensuring the property always returns an HTTPMethod.
| /// 제보를 등록합니다. | ||
| /// - Parameters: | ||
| /// - title: 제보 제목 | ||
| /// - content: 제보 내용 | ||
| /// - category: 제보 카테고리 | ||
| /// - location: 제보 위치 | ||
| /// - photos: 업로드한 사진의 presigned urls | ||
| /// - Returns: 제보 id | ||
| func report( | ||
| title: String, | ||
| content: String?, | ||
| category: ReportType, | ||
| location: LocationEntity?, | ||
| photoURLs: [String] | ||
| ) async throws -> Int? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
report 파라미터 주석이 시그니처 및 실제 값 의미와 맞지 않습니다
현재 주석과 구현 사이에 두 가지 작은 불일치가 있습니다.
- 파라미터 이름은
photoURLs인데, 주석에는photos라고 되어 있음 - 주석에는 “presigned urls”라고 되어 있지만, 실제로
ReportUseCase에서는publicImageURLs(query 스트립된 공개 URL)을 넘기고 있어, presigned URL이라 보기 어렵습니다
혼동을 줄이기 위해 주석을 아래처럼 맞춰 주는 것을 제안드립니다.
- /// - photos: 업로드한 사진의 presigned urls
+ /// - photoURLs: 업로드가 완료된 사진의 공개 URL 목록
/// - Returns: 제보 id
func report(
title: String,
content: String?,
category: ReportType,
location: LocationEntity?,
- photoURLs: [String]
+ photoURLs: [String]
) async throws -> Int?이렇게 해 두면, 호출 측에서도 “파일 업로드까지 끝난 뒤 최종적으로 저장되는 URL을 넘긴다”는 의도가 더 명확해질 것 같습니다.
🤖 Prompt for AI Agents
In Projects/Domain/Sources/Protocol/Repository/ReportRepositoryProtocol.swift
around lines 12 to 26, the parameter doc is inconsistent with the signature and
usage: update the @param for photoURLs (replace "photos") and remove "presigned
urls", describing that the method expects final public image URLs
(query-stripped) as used by ReportUseCase; adjust the brief so callers know
these are the final stored/public URLs passed after upload.
| public func report( | ||
| title: String, | ||
| content: String?, | ||
| category: ReportType, | ||
| location: LocationEntity?, | ||
| photos: [Data] | ||
| ) async throws -> Int? { | ||
| if photos.isEmpty { return nil } | ||
|
|
||
| let fileNames = (1...photos.count).map { "\($0).jpg" } | ||
|
|
||
| // TODO: - 사진 업로드 실패 시 에러 처리 필요 | ||
| guard | ||
| let presignedDict = try await fileRepository.fetchPresignedURL(prefix: "report", fileNames: fileNames), | ||
| presignedDict.count == photos.count | ||
| else { return nil } | ||
|
|
||
| let presignedURLs = fileNames.compactMap { fileName in | ||
| presignedDict[fileName] | ||
| } | ||
|
|
||
| for (url, photo) in zip(presignedURLs, photos) { | ||
| do { | ||
| try await fileRepository.uploadFile(url: url, data: photo) | ||
| } catch { | ||
| print(error.localizedDescription) | ||
| } | ||
| } | ||
|
|
||
| let publicImageURLs = presignedURLs.map { url in | ||
| url.split(separator: "?", maxSplits: 1) | ||
| .map(String.init) | ||
| .first ?? url | ||
| } | ||
|
|
||
| return try await reportRepository.report( | ||
| title: title, | ||
| content: content, | ||
| category: category, | ||
| location: location, | ||
| photoURLs: publicImageURLs) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
사진 업로드 실패 시 조용히 진행되는 현재 구현은 정합성 문제가 될 수 있습니다
report(...) 내부에서 사진 업로드 플로우가 다음과 같이 동작하는 점이 조금 위험해 보입니다.
fetchPresignedURL실패/개수 불일치 시에는nil반환으로 조용히 종료- 업로드 루프에서 각
uploadFile에러를print만 하고 무시한 뒤,
실패 여부와 상관없이reportRepository.report(...)를 호출 - 결과적으로 서버에는 실제로 업로드되지 않은 이미지 URL이 포함된 제보가 등록될 수 있음
실제 서비스 관점에서는 “사진이 일부/전부 업로드되지 않았는데도 성공처럼 보이는 제보”가 생길 수 있어, 최소한 전체 업로드 실패 시에는 에러를 throw 하거나, 업로드된 것만 필터링해서 전달하는 등의 처리가 필요해 보입니다. SwiftLint도 TODO를 경고하고 있으니 이 부분을 이번 PR에서 함께 정리하는 것이 좋겠습니다.
한 가지 단순한 개선안은 “업로드 실패 시 전체 제보 등록을 중단하고 에러를 전파”하는 것입니다.
- // TODO: - 사진 업로드 실패 시 에러 처리 필요
- guard
- let presignedDict = try await fileRepository.fetchPresignedURL(prefix: "report", fileNames: fileNames),
- presignedDict.count == photos.count
- else { return nil }
+ guard
+ let presignedDict = try await fileRepository.fetchPresignedURL(prefix: "report", fileNames: fileNames),
+ presignedDict.count == photos.count
+ else {
+ // presigned URL 개수 불일치는 로직/서버 문제에 가까우므로 에러로 보고합니다.
+ throw ReportError.invalidPresignedURLCount
+ }
@@
- for (url, photo) in zip(presignedURLs, photos) {
- do {
- try await fileRepository.uploadFile(url: url, data: photo)
- } catch {
- print(error.localizedDescription)
- }
- }
+ for (url, photo) in zip(presignedURLs, photos) {
+ // 업로드 실패 시 바로 throw 되어 전체 제보 등록을 중단합니다.
+ try await fileRepository.uploadFile(url: url, data: photo)
+ }위 예시의 ReportError.invalidPresignedURLCount는 도메인 에러 enum 등을 새로 정의해야 하므로, 실제 구현에서는 프로젝트에서 이미 사용 중인 에러 타입에 맞춰 조정해 주시면 됩니다.
추가로:
photos.isEmpty { return nil }도 “사진이 필수인지 여부”에 따라throw로 validation 실패를 알려주는 쪽이 더 디버깅에 유리할 수 있습니다.presignedURLs.count == photos.count여부를 한 번 더 guard로 확인해 두면, key mismatch 시 일부 사진만 업로드되는 상황도 방지할 수 있습니다.
Committable suggestion skipped: line range outside the PR's diff.
🧰 Tools
🪛 SwiftLint (0.57.0)
[Warning] 50-50: TODOs should be resolved (- 사진 업로드 실패 시 에러 처리 필요)
(todo)
| private func configureLocation() { | ||
| Task { | ||
| do { | ||
| self.location = try await reportUseCase.fetchCurrentLocation() | ||
| locationSubject.send(location?.address) | ||
| verifyIsReportValid() | ||
| } catch { | ||
|
|
||
| locationSubject.send(nil) | ||
| verifyIsReportValid() | ||
| } | ||
|
|
||
| locationSubject.send(location?.address) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
위치 조회 실패 시 location 프로퍼티가 초기화되지 않아 검증/실제 등록 상태가 어긋날 수 있습니다
configureLocation()의 catch 블록에서 locationSubject는 nil을 보내지만, self.location은 그대로 유지됩니다. 이 상태에서 verifyIsReportValid()는 이전에 성공했던 위치값을 계속 보고 true를 줄 수 있고, register()도 이전 위치로 신고를 보냅니다. UI에는 위치가 비어 보이는데 실제로는 이전 좌표로 신고되는 상황이 생길 수 있습니다.
에러 시 self.location도 함께 nil로 초기화하는 쪽이 더 일관될 것 같습니다.
private func configureLocation() {
Task {
do {
self.location = try await reportUseCase.fetchCurrentLocation()
locationSubject.send(location?.address)
verifyIsReportValid()
} catch {
- locationSubject.send(nil)
- verifyIsReportValid()
+ self.location = nil
+ locationSubject.send(nil)
+ verifyIsReportValid()
}
}
}또한 검증 로직(verifyIsReportValid)에서 lat/long까지 체크해 주고 있으니, 이 상태라면 등록/버튼 활성 조건 모두 동일하게 맞춰집니다.
Also applies to: 174-190
🤖 Prompt for AI Agents
In
Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift
around lines 99-110 (and similarly 174-190), the catch path for
configureLocation() only sends nil to locationSubject but leaves self.location
unchanged, causing validation and registration to use stale coordinates; update
the catch block(s) to also set self.location = nil before sending
locationSubject.send(nil) and calling verifyIsReportValid(), and ensure
verifyIsReportValid() validates presence of latitude/longitude (not just non-nil
location) so button enablement and register() use the same lat/long checks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift (1)
300-311: 레이아웃 제약 조건을 수정해야 합니다.
locationTitleLabel의 top 제약이reportContentTextView.snp.bottom을 참조하고 있지만, 이제contentTextCountLabel이 그 사이에 추가되었습니다. Lines 288-298에서contentTextCountLabel을reportContentTextView아래에 배치했으므로,locationTitleLabel은contentTextCountLabel.snp.bottom을 기준으로 배치되어야 합니다. 현재 상태로는 두 요소가 겹치거나contentTextCountLabel이 올바른 위치에 표시되지 않습니다.다음과 같이 수정하세요:
locationTitleLabel.snp.makeConstraints { make in make.top - .equalTo(reportContentTextView.snp.bottom) + .equalTo(contentTextCountLabel.snp.bottom) .offset(Layout.titleLabelTopSpacing) make.leading .equalToSuperview() .offset(Layout.horizontalInset) make.height .equalTo(Layout.titleLabelHeight) }
♻️ Duplicate comments (4)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (2)
50-62: 이전 리뷰에서 지적된 에러 처리 문제가 여전히 남아있습니다사진 업로드 실패 시 조용히 진행되는 현재 구현(TODO 주석, silent nil 반환, print만 하는 에러 처리)은 이전 리뷰 코멘트에서 이미 상세히 지적되었습니다. 업로드 실패 시 전체 제보 등록을 중단하고 에러를 전파하는 방식으로 개선이 필요합니다.
이전 리뷰 코멘트를 참고하여 에러 처리를 개선해 주세요.
46-46: 사진 필수 여부 확인 필요사진 배열이 비어있을 때
nil을 반환하는데, 이것이 의도된 동작인지 확인이 필요합니다. 만약 제보에 사진이 필수라면 에러를 throw하여 validation 실패를 명확히 알리는 것이 디버깅에 유리합니다.사진이 필수인 경우 다음과 같이 수정할 수 있습니다:
- if photos.isEmpty { return nil } + guard !photos.isEmpty else { + throw ReportError.photosRequired + }Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift (1)
111-114: 이전 리뷰 지적사항이 여전히 해결되지 않았습니다이전 리뷰에서 지적된 문제가 그대로 남아있습니다.
catch블록에서locationSubject에만nil을 전송하고self.location은 초기화하지 않아, 이전에 성공했던 위치 값이 유지될 수 있습니다. 현재verifyIsReportValid()에서 위도/경도를 검증하고 있지만, 명시적으로self.location = nil로 초기화하는 것이 더 안전합니다.private func configureLocation() { Task { do { self.location = try await reportUseCase.fetchCurrentLocation() locationSubject.send(location?.address) verifyIsReportValid() } catch { + self.location = nil locationSubject.send(nil) verifyIsReportValid() } } }Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (1)
83-87:configureAttribute/configureLayout중복 호출 이슈가 여전히 남아 있습니다
BaseViewController의viewDidLoad()에서 이미configureAttribute(),configureLayout(),bind()를 호출하는 패턴이라면, 여기서 다시 호출하면 서브뷰/제약이 중복으로 잡힐 수 있습니다. Auto Layout 경고나 예상치 못한 UI 깨짐이 나올 수 있어, 이 VC에서도 한 번만 호출되도록 정리하는 편이 안전해 보입니다.아래처럼
viewDidLoad에서는 액션만 트리거하도록 바꾸는 걸 제안드립니다.override func viewDidLoad() { super.viewDidLoad() - configureAttribute() - configureLayout() viewModel.action(input: .fetchReportDetail(reportId: reportId)) }Based on learnings, BaseViewController.viewDidLoad()에서 이미 configureAttribute/configureLayout/bind()를 호출하는 것으로 알고 있습니다.
🧹 Nitpick comments (3)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (3)
121-141: 탭바 VC 인덱스/캐스팅에 대한 방어 로직을 두는 편이 안전합니다
tabBarController.viewControllers?[0/1/2]에 바로 인덱스로 접근하고UINavigationController로 캐스팅하는 구현은, 탭 구성이 바뀌거나 VC 수가 줄어들면 인덱스 범위 초과로 크래시가 날 수 있습니다.예를 들어 아래처럼
viewControllers개수와 타입을 한 번에 검증하는 쪽을 고려해 볼 수 있습니다.guard let tabBarController = self.tabBarController, let viewControllers = tabBarController.viewControllers, viewControllers.count >= 3, let homeNav = viewControllers[0] as? UINavigationController, let routineNav = viewControllers[1] as? UINavigationController, let myPageNav = viewControllers[2] as? UINavigationController, let reportHistoryViewModel = DIContainer.shared.resolve(type: ReportHistoryViewModel.self) else { self?.navigationController?.popToRootViewController(animated: true) return } homeNav.popToRootViewController(animated: false) routineNav.popToRootViewController(animated: false) tabBarController.selectedIndex = 2 myPageNav.pushViewController(ReportHistoryViewController(viewModel: reportHistoryViewModel), animated: true)현재 구조가 고정이라면 동작에는 문제가 없겠지만, 탭 구성이 바뀔 수 있는 앱이라면 위와 같이 방어 로직을 추가하는 것을 추천드립니다.
206-237:summaryStackView에 컨텐츠가 재바인딩 시 중복 추가될 수 있습니다
bind()에서:ReportCompleteContent.allCases.forEach { ... } self?.summaryStackView.addArrangedSubview(contentStackView ?? UIView())를 매 emit마다 실행하지만,
summaryStackView의 기존 컨텐츠(특히 첫 바인딩 때 추가한 행들)를 지우지 않고 있습니다.reportDetailPublisher가 여러 번 emit될 가능성이 있다면, 동일한 행이 반복해서 쌓일 수 있습니다. (photoStackView는 이미 잘 비워주고 있어서 동일 패턴만 summaryStackView에 적용하면 될 것 같습니다.)한 번만 레이아웃을 구성하고 이후에는 값만 업데이트하는 식으로 바꾸면 더 안전합니다. 예를 들면:
private var didSetupSummaryContent = false override func bind() { viewModel.output.reportDetailPublisher .receive(on: DispatchQueue.main) .sink { [weak self] reportDetail in guard let self, let reportDetail else { return } if !self.didSetupSummaryContent { ReportCompleteContent.allCases.forEach { type in let contentStackView = self.makeContentView(contentType: type) self.summaryStackView.addArrangedSubview(contentStackView) } self.didSetupSummaryContent = true } // 이후 title/category/location/description, photoStackView 업데이트 } .store(in: &cancellables) }또한
makeContentView는 non-optional을 반환하므로contentStackView ?? UIView()부분은 불필요하고, 위처럼guard let self이후에는self를 강한 참조로 사용하는 편이 코드도 더 단순해집니다.
60-61:backgroudView프로퍼티 네이밍 오타
backgroudView→backgroundView로 보이는 오타입니다. 동작에는 문제가 없지만, 추후 검색/리팩토링 시 혼동을 줄이기 위해 한 번에 이름을 정리해 두는 편이 좋겠습니다.예:
-private let backgroudView = UIView() +private let backgroundView = UIView() ... - backgroudView.backgroundColor = BitnagilColor.gray98 - backgroudView.layer.masksToBounds = true - backgroudView.layer.cornerRadius = 12 + backgroundView.backgroundColor = BitnagilColor.gray98 + backgroundView.layer.masksToBounds = true + backgroundView.layer.cornerRadius = 12 ... - backgroudView.snp.makeConstraints { make in + backgroundView.snp.makeConstraints { make in ... }규모가 큰 변경은 아니라, 이번 PR이나 후속 UI 리팩토링 때 함께 정리해도 좋을 것 같습니다.
Also applies to: 107-110, 187-191
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift(2 hunks)Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift(1 hunks)Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift(5 hunks)Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift(16 hunks)Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift(7 hunks)Projects/Shared/Sources/Extension/Date+.swift(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- Projects/Shared/Sources/Extension/Date+.swift
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-07-16T09:21:15.038Z
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 19
File: Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift:57-59
Timestamp: 2025-07-16T09:21:15.038Z
Learning: OnboardingRecommendedRoutineView에서 viewWillAppear에 registerOnboarding 호출하는 것이 적절한 이유: 사용자가 이전 페이지에서 온보딩 선택지를 변경한 후 돌아올 때 새로운 선택지로 다시 등록해야 하기 때문. 홈 뷰에서는 이 뷰로 돌아올 수 없어서 중복 호출 문제가 발생하지 않음.
Applied to files:
Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift
📚 Learning: 2025-11-19T13:49:08.326Z
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 68
File: Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift:108-172
Timestamp: 2025-11-19T13:49:08.326Z
Learning: ReportCompleteViewController에서 backgroudView가 fomoImageView와 의도적으로 겹치도록 설계됨. 이를 위해 backgroudView의 top 제약을 fomoImageView.snp.top (또는 fomoImageView)을 기준으로 설정하여 오프셋을 적용함.
Applied to files:
Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swiftProjects/Presentation/Sources/Report/View/ReportRegistrationViewController.swiftProjects/Presentation/Sources/Report/View/ReportCompleteViewController.swift
📚 Learning: 2025-07-16T09:09:13.869Z
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 19
File: Projects/Presentation/Sources/Login/View/TermsAgreementView.swift:44-46
Timestamp: 2025-07-16T09:09:13.869Z
Learning: BaseViewController의 viewDidLoad() 메서드에서 이미 configureAttribute(), configureLayout(), bind()를 호출하므로, 하위 클래스에서 super.viewDidLoad()를 호출하면 이 메서드들이 자동으로 호출된다. 따라서 하위 클래스에서 추가로 호출할 필요가 없다.
Applied to files:
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift
🧬 Code graph analysis (4)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (2)
Projects/DataSource/Sources/Repository/ReportRepository.swift (3)
fetchReports(41-53)fetchReportDetail(55-61)report(14-39)Projects/DataSource/Sources/Repository/FileRepository.swift (2)
fetchPresignedURL(14-19)uploadFile(21-24)
Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift (2)
Projects/Shared/Sources/DIContainer/DIContainer.swift (1)
resolve(18-25)Projects/Presentation/Sources/Report/View/ReportLoadingViewController.swift (1)
reportRegistrationViewController(84-95)
Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift (2)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift (2)
fetchCurrentLocation(25-29)report(39-76)Projects/DataSource/Sources/Repository/ReportRepository.swift (1)
report(14-39)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (4)
Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift (1)
viewDidLoad(71-74)Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift (2)
action(32-37)fetchReportDetail(39-61)Projects/DataSource/Sources/Repository/ReportRepository.swift (1)
fetchReportDetail(55-61)Projects/Shared/Sources/DIContainer/DIContainer.swift (1)
resolve(18-25)
🪛 SwiftLint (0.57.0)
Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift
[Warning] 50-50: TODOs should be resolved (- 사진 업로드 실패 시 에러 처리 필요)
(todo)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (9)
Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift (1)
180-196: 검증 로직이 잘 구현되었습니다모든 필수 필드(제목, 카테고리, 내용, 위치 및 좌표, 사진)를 포괄적으로 검증하고 있으며, 특히 위도/경도 확인을 추가하여 이전 리뷰의 우려사항을 해결했습니다.
Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift (8)
14-16: LGTM!델리게이트 프로토콜이 적절하게 정의되었으며, 제보 등록 완료 시 reportId를 전달하는 구조가 명확합니다.
120-125: LGTM!로딩 화면을 먼저 표시한 후 등록 액션을 트리거하는 플로우가 적절합니다. 델리게이트 패턴을 통해 완료 시점을 비동기로 전달하는 구조가 잘 설계되었습니다.
133-135: LGTM!키보드 dismiss 제스처 추가로 사용자 경험이 개선되었습니다.
cancelsTouchesInView = false설정으로 다른 터치 이벤트를 방해하지 않는 점도 적절합니다.Also applies to: 537-539
209-272: LGTM!폼 필드 순서를 재구성하여 제목 → 카테고리 → 내용 순으로 변경한 것이 사용자 경험 측면에서 자연스러운 흐름입니다. 제약 조건이 새로운 레이아웃에 맞게 올바르게 설정되었습니다.
411-424: LGTM!사진 최대 개수 검증 로직이 적절하게 구현되었습니다. 사용자가 최대 개수에 도달했을 때 명확한 알림을 제공하고, 그렇지 않은 경우에만 카메라/앨범 선택 화면을 표시하는 플로우가 잘 설계되었습니다.
426-435: LGTM!폼 유효성 검사를 반응형으로 처리하여 제출 버튼의 상태를 동적으로 업데이트하는 구현이 적절합니다. 사용자가 필수 항목을 입력하기 전까지 제출을 방지하는 좋은 UX 패턴입니다.
437-444: LGTM!등록 완료 시 델리게이트를 통해 reportId를 전달하는 구조가 명확하고 적절합니다. 비동기 완료를 처리하는 패턴이 잘 구현되었습니다.
621-637: LGTM!PHPicker에서 여러 사진을 선택할 수 있도록 개선되었습니다. 각 결과를 개별적으로 처리하며, 이미지 로드가 실패한 경우 적절히 스킵하는 로직이 잘 구현되었습니다. 다만, ViewModel에서 총 사진 개수가 최대값을 초과하지 않도록 검증하는 로직이 있는지 확인하세요.
| ) async throws -> Int? { | ||
| if photos.isEmpty { return nil } | ||
|
|
||
| let fileNames = (1...photos.count).map { "\($0).jpg" } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
파일명 충돌 위험: 고유 식별자 추가 필요
순차 번호(1.jpg, 2.jpg)만으로 파일명을 생성하면 여러 사용자가 동시에 업로드할 때 파일명이 중복될 수 있습니다. S3 prefix로 report를 사용하더라도 같은 시간대에 업로드되는 파일들 간 충돌이 발생할 수 있습니다.
UUID나 타임스탬프를 포함한 고유 파일명 생성 방식으로 변경하는 것을 권장합니다.
- let fileNames = (1...photos.count).map { "\($0).jpg" }
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
+ let fileNames = (1...photos.count).map { "\(timestamp)_\($0).jpg" }또는 UUID를 사용하는 방법:
- let fileNames = (1...photos.count).map { "\($0).jpg" }
+ let fileNames = (1...photos.count).map { "\(UUID().uuidString).jpg" }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let fileNames = (1...photos.count).map { "\($0).jpg" } | |
| let timestamp = Int(Date().timeIntervalSince1970 * 1000) | |
| let fileNames = (1...photos.count).map { "\(timestamp)_\($0).jpg" } |
| let fileNames = (1...photos.count).map { "\($0).jpg" } | |
| let fileNames = (1...photos.count).map { "\(UUID().uuidString).jpg" } |
🤖 Prompt for AI Agents
In Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift around line 48,
the code generates filenames as "1.jpg", "2.jpg" which risks collisions when
multiple users/upload batches occur; change the filename generation to include a
unique identifier (e.g., append/prepend a UUID or timestamp to each name) so
each photo becomes something like "<uuid>_<index>.jpg" or
"<timestamp>_<index>.jpg"; ensure the chosen identifier is generated per file
(not per batch unless acceptable), preserve the .jpg extension, and update any
downstream code that relies on the filename format.
| for (url, photo) in zip(presignedDict.values, photos) { | ||
| do { | ||
| try await fileRepository.uploadFile(url: url, data: photo) | ||
| } catch { | ||
| print(error.localizedDescription) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
치명적 버그: Dictionary 순서 불일치로 인한 사진-URL 매핑 오류
Swift의 Dictionary.values는 순서가 보장되지 않습니다. Line 56에서 presignedDict.values를 photos 배열과 zip으로 매핑하면, 실제 의도한 사진과 다른 presigned URL로 업로드될 수 있습니다. 예를 들어 1.jpg용 URL에 2.jpg 데이터가 업로드되는 등의 데이터 정합성 문제가 발생합니다.
파일명을 키로 사용하여 올바른 URL과 사진을 매핑해야 합니다:
- for (url, photo) in zip(presignedDict.values, photos) {
+ for (fileName, photo) in zip(fileNames, photos) {
+ guard let url = presignedDict[fileName] else {
+ throw FileUploadError.missingPresignedURL
+ }
do {
try await fileRepository.uploadFile(url: url, data: photo)
} catch {
print(error.localizedDescription)
}
}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift around lines
56–62, the code zips presignedDict.values with photos which is unsafe because
Dictionary.values is unordered; instead, map each photo to its presigned URL
using the filename (or the exact key used in presignedDict) and upload by
looking up presignedDict[photoFilename] for each photo, handling the missing-URL
case (log/throw) and keeping the await/try semantics inside that loop so each
photo is uploaded to its correct URL.
| let publicImageURLs = presignedDict.values.map { url in | ||
| url.split(separator: "?", maxSplits: 1) | ||
| .map(String.init) | ||
| .first ?? url | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dictionary 순서 불일치: 공개 URL 배열의 순서가 보장되지 않음
Line 64에서도 presignedDict.values를 사용하여 공개 URL 배열을 생성하는데, Dictionary의 순서가 보장되지 않아 실제 업로드된 사진과 다른 순서의 URL이 서버에 전달될 수 있습니다.
파일명 순서를 기준으로 URL을 추출해야 합니다:
- let publicImageURLs = presignedDict.values.map { url in
+ let publicImageURLs = fileNames.compactMap { fileName in
+ guard let url = presignedDict[fileName] else { return nil }
+ return url.split(separator: "?", maxSplits: 1)
- url.split(separator: "?", maxSplits: 1)
.map(String.init)
.first ?? url
}🤖 Prompt for AI Agents
In Projects/Domain/Sources/UseCase/Report/ReportUseCase.swift around lines 64 to
68, the code uses presignedDict.values which does not guarantee order and can
misalign URLs with uploaded photos; instead build the publicImageURLs array by
iterating over the expected file order (e.g., the original filenames array or
sorted keys) and look up each filename in presignedDict to produce URLs in the
correct sequence, handling missing entries gracefully (skip or provide fallback)
so the resulting array matches the photo upload order.
| func floatingMenuDidTapReportButton(_ sender: FloatingMenuView) { | ||
| toggleFloatingButton() | ||
| // TODO: 제보하기 뷰로 이동 | ||
|
|
||
| guard let reportRegistrationViewModel = DIContainer.shared.resolve(type: ReportRegistrationViewModel.self) | ||
| else { fatalError("reportRegistrationViewController 의존성이 등록되지 않았습니다.") } | ||
|
|
||
| let reportRegistrationViewController = ReportRegistrationViewController(viewModel: reportRegistrationViewModel) | ||
| reportRegistrationViewController.hidesBottomBarWhenPushed = true | ||
|
|
||
| self.navigationController?.pushViewController(reportRegistrationViewController, animated: true) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
오류 메시지 수정이 필요합니다.
Line 342의 fatalError 메시지가 실제로 resolve하는 타입과 일치하지 않습니다. ReportRegistrationViewModel을 resolve하고 있지만 메시지는 "reportRegistrationViewController"라고 표시되어 있어 디버깅 시 혼란을 줄 수 있습니다.
다음과 같이 수정하세요:
guard let reportRegistrationViewModel = DIContainer.shared.resolve(type: ReportRegistrationViewModel.self)
- else { fatalError("reportRegistrationViewController 의존성이 등록되지 않았습니다.") }
+ else { fatalError("reportRegistrationViewModel 의존성이 등록되지 않았습니다.") }🤖 Prompt for AI Agents
In
Projects/Presentation/Sources/RecommendedRoutine/View/RecommendedRoutineViewController.swift
around lines 338–348, the fatalError on line 342 logs the wrong dependency name
("reportRegistrationViewController") while resolving
ReportRegistrationViewModel; update the error message to reference the actual
type being resolved (e.g., "ReportRegistrationViewModel 의존성이 등록되지 않았습니다.") so
the log accurately reflects the missing dependency and aids debugging.
| let content = content ?? "" | ||
| self?.contentTextCountLabel.text = "\(content.count) / 150" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for ViewModel file related to Report Registration
find Projects -name "*ViewModel*" -type f | head -20Repository: YAPP-Github/Bitnagil-iOS
Length of output: 1301
🏁 Script executed:
#!/bin/bash
# Search for content length validation and UITextView handling
rg -n "150|maxLength|max.*length|inputContent|content.*limit" --type=swift -i | head -40Repository: YAPP-Github/Bitnagil-iOS
Length of output: 2218
🏁 Script executed:
#!/bin/bash
# Look for ReportRegistrationViewController class definition and content handling
rg -n "class ReportRegistrationViewController|contentPublisher|UITextViewDelegate" --type=swift -B2 -A2Repository: YAPP-Github/Bitnagil-iOS
Length of output: 3960
🏁 Script executed:
#!/bin/bash
# Read ReportRegistrationViewModel to check for content validation logic
cat -n Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swiftRepository: YAPP-Github/Bitnagil-iOS
Length of output: 8338
🏁 Script executed:
#!/bin/bash
# Read ReportTextView to check UITextViewDelegate implementation
cat -n Projects/Presentation/Sources/Report/View/Component/ReportRegistration/ReportTextView.swiftRepository: YAPP-Github/Bitnagil-iOS
Length of output: 6471
🏁 Script executed:
#!/bin/bash
# Check ReportRegistrationViewController around lines 375-381 for full context
sed -n '360,400p' Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift | cat -nRepository: YAPP-Github/Bitnagil-iOS
Length of output: 1966
Add 150-character validation for report content input.
The UI displays character count as "count / 150", but the ViewModel does not enforce this limit. The configureContent() method (line 100-103 in ReportRegistrationViewModel.swift) accepts any length without truncation or validation. Add shouldChangeTextIn delegate method to ReportTextView to prevent input exceeding 150 characters, similar to the pattern used in WithdrawViewController with its 100-character limit.
🤖 Prompt for AI Agents
In
Projects/Presentation/Sources/Report/View/ReportRegistrationViewController.swift
around lines 380-381, the UI shows "count / 150" but input is not limited; add a
UITextViewDelegate implementation for ReportTextView
(shouldChangeTextIn:range:replacementText:) that computes the prospective text
length and returns false if it would exceed 150 characters to prevent further
input, mirror the pattern used in WithdrawViewController for the 100-char limit;
also update ReportRegistrationViewModel.configureContent() (lines ~100-103 in
ReportRegistrationViewModel.swift) to enforce/truncate to 150 characters on
programmatic changes (trim incoming content to 150, assign trimmed string, and
update the content count binding) so both user typing and programmatic sets
never exceed 150 and the contentTextCountLabel remains accurate.
| guard | ||
| let name = titleSubject.value, | ||
| !name.isEmpty, | ||
| let category = categorySubject.value, | ||
| let content = contentSubject.value, | ||
| let location, | ||
| selectedPhotoSubject.value.count > 0 | ||
| else { return } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
검증 로직이 verifyIsReportValid()와 불일치합니다
register()의 가드문은 location의 존재만 확인하고 위도/경도는 검증하지 않습니다. 반면 verifyIsReportValid() (187-188번 줄)는 location.latitude와 location.longitude가 nil이 아닌지 확인합니다.
이로 인해 버튼은 활성화되었지만, location에 좌표가 없는 경우 등록 시 문제가 발생할 수 있습니다. 일관성을 위해 register()의 가드문에도 동일한 위도/경도 검증을 추가해야 합니다.
private func register() {
guard
let name = titleSubject.value,
!name.isEmpty,
let category = categorySubject.value,
let content = contentSubject.value,
let location,
+ location.latitude != nil,
+ location.longitude != nil,
selectedPhotoSubject.value.count > 0
else { return }🤖 Prompt for AI Agents
In
Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift
around lines 141-148, the guard in register() only checks that location exists
but does not validate location.latitude and location.longitude like
verifyIsReportValid() does (lines ~187-188), causing inconsistent validation;
update the guard to also unwrap/verify that location.latitude and
location.longitude are non-nil (or use the same validation helper used by
verifyIsReportValid()) before proceeding so register() and verifyIsReportValid()
remain consistent.
| do { | ||
| reportId = try await reportUseCase.report( | ||
| title: name, | ||
| content: content, | ||
| category: category, | ||
| location: location, | ||
| photos: selectedPhotos) | ||
| } catch { | ||
| reportId = nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
에러 발생 시 사용자에게 피드백이 없습니다
catch 블록에서 에러를 조용히 무시하고 reportId = nil만 설정합니다. 사용자는 등록이 실패한 이유(네트워크 오류, 서버 오류 등)를 알 수 없어 혼란스러울 수 있습니다.
exceptionSubject를 통해 에러 메시지를 전달하거나, 별도의 에러 상태를 방출하여 UI에서 적절한 피드백을 제공하는 것이 좋습니다.
do {
reportId = try await reportUseCase.report(
title: name,
content: content,
category: category,
location: location,
photos: selectedPhotos)
} catch {
+ await MainActor.run {
+ exceptionSubject.send("제보 등록 중 오류가 발생했습니다. 다시 시도해주세요.")
+ }
reportId = nil
}🤖 Prompt for AI Agents
In
Projects/Presentation/Sources/Report/ViewModel/ReportRegistrationViewModel.swift
around lines 157-166, the catch block swallows errors and only sets reportId =
nil; instead capture the caught error and emit a user-facing error (e.g., send a
localized message or error object through exceptionSubject or set an error state
property) so the UI can show feedback; update the catch to map the error to a
user-friendly message (or pass the raw error if appropriate) and publish it via
exceptionSubject (or the view model’s error publisher), and keep setting
reportId = nil if needed.
🌁 Background
👩💻 Contents
👩💻 2차 Contents (25.12.04)
📝 Review Note
1. 제보하기 이미지 선택 수정
기존
ReportRegistrationViewController에서 이미지 선택 시,itemProvider = results.first?.itemProvider만 확인하여 만약 3장의 이미지를 선택하였더라도 첫번째 선택한 이미지만 적용되었던 버그가 있었습니다 !!
따라서 Results를 for문으로 돌면서 확인하는 식으로 약간 수정했어유 !!!!
또한 기존에는 현재 선택된 이미지가 3장이여도 bottom sheet를 통해 이미지나 사진 촬영을 허락한 후 이미지 선택이 적용될 때 Alert를 띄워주는 형식으로 흘러갔는데
약간 유저 입장에서 어색할 수 있다고 판단되어 ViewModel에 selectedPhotoCountPublisher를 추가해
유저가 카메라 버튼을 눌렀을 때 selectedPhotoCountPublisher를 통해 이미 max 까지 선택되었다면 Alert 아니라면 Bottom Sheet를 보여주는 식으로 수정하였습니다 !!!
그 외에 수정사항들은 매우 미비함니다 ~~~ 땽큐 딩동 !
Summary by CodeRabbit
개선사항
신규
리팩토링
✏️ Tip: You can customize this high-level summary in your review settings.